[ 롤모임 운영일지 ] - 27. egress 20.82GB로 프로젝트가 정지됐다 — 횟수를 줄였는데 바이트가 안 줄던 이유
9월 중순, Supabase 프로젝트가 정지됐다. 이번 청구 기간 egress가 20.82GB였다. 무료 한도는 5GB다.
접속자는 하루 40~50명인 모임 서비스다. 이미지를 잔뜩 서빙하는 것도 아니다 — Storage는 34MB밖에 안 쓴다. 20GB는 전부 DB를 읽어서 나간 바이트였다.
원인을 세 번 틀리게 짚고 나서야 제대로 잡았다. 이번 편은 그 세 번의 기록이다. 결론부터 말하면 첫 번째 조치는 요청 횟수를 95% 줄였는데 egress는 1바이트도 안 줄었다.
TL;DR
- 1차 진단: 인증 미들웨어가 요청마다 던지는 쿼리가 하루 43만 건. 접속자 45명이면 1인당 1만 건 — 사람이 그만큼 누를 리 없으니 폴링이 범인이다. 탭이 숨겨져도 도는 폴링을
visibilitychange기반으로 바꿔 43만 → 2만 건/일로 줄였다. - 그런데 egress는 그대로였다. 인증 쿼리는 1행짜리라 바이트가 전체의 약 1%였다. 호출 “횟수”를 줄였지 “바이트”를 줄인 게 아니었다.
- 진짜 원인은
ChampionPool.data— 한 행 평균 5,005B인 마스터리 JSON 전문. 하루 1,677MB로 전체의 **63.2%**였다. 화면이 실제로 쓰는 건 그 안의 조각 몇 개(이름 3개 60B 등)뿐이다. - 고친 결과(EXPLAIN SERIALIZE 실측): 구인 목록 1회 964kB → 9kB(107배), 솔랭 연승왕 1,472kB → 2kB(736배).
- 재발 방지는 타입 검사로 불가능하다(
championPool: true는 늘 유효한 코드다). 소스를 훑는 테스트를 짰는데, 그 검사기가 처음엔 틀려서 고친 코드를 되돌려도 통과했다. - 덤으로 5분짜리 장애를 하나 냈다(3절).
1. 어디서 나갔는지부터
Supabase 대시보드에서 항목별로 보면 답이 바로 나온다.
Shared Pooler 2.182 GB ← DB
Auth 12.9 MB
Storage 29.8 KB
(2026-09-16 하루치 실측) 이미지도 인증도 아니고 전부 DB다. 그래서 pg_stat_statements의 155일 누적을 일평균으로 환산했다.
AccountPermission 174,355 건/일
Membership 171,841 건/일
Membership(2차) 44,149 건/일
Group 구독 43,771 건/일
넷 다 인증 미들웨어가 요청마다 던지는 것이다. 요청 하나에 DB를 네 번 친다.
그런데 접속자는 하루 40~50명이다. 1인당 1만 건이면 사람이 누른 게 아니다. 폴링이 만든 숫자다.
2. 1차 조치 — 탭이 숨겨진 동안은 쉬게 한다
알림 60초, 확성기 2분, 동전던지기 10초 주기 폴링이 탭을 숨겨도 계속 돌고 있었다. MVP 투표만 visibility를 보고 있었다. 사람들은 앱을 켜 둔 채 딴 일을 하니, 그 시간이 하루의 대부분이다.
훅 하나로 통일했다.
// packages/frontend/src/hooks/usePolling.ts
export function usePolling(fn: () => void, intervalMs: number, enabled = true): void {
const fnRef = useRef(fn);
fnRef.current = fn;
useEffect(() => {
if (!enabled || intervalMs <= 0) return;
let timer: ReturnType<typeof setInterval> | null = null;
const tick = () => fnRef.current();
const start = () => { if (!timer) timer = setInterval(tick, intervalMs); };
const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
const onVisibility = () => {
if (document.visibilityState === "visible") {
tick(); // 복귀 즉시 1회 — 숨어 있던 동안의 공백을 메운다
start();
} else {
stop();
}
};
// 처음 붙을 때 이미 숨겨진 탭이면 시작하지 않는다(백그라운드 렌더 대비).
if (document.visibilityState === "visible") start();
document.addEventListener("visibilitychange", onVisibility);
return () => {
stop();
document.removeEventListener("visibilitychange", onVisibility);
};
}, [intervalMs, enabled]);
}
한 가지를 조심했다. 탭으로 돌아오면 즉시 한 번 부른다. 이게 없으면 “복귀 후 최대 interval만큼 옛 데이터”가 되어, 아낀 요청을 체감 저하로 지불하게 된다. 보고 있는 동안의 신선도는 예전과 같고, 숨어 있는 동안만 쉰다.
fn을 ref로 들고 있는 것도 의도적이다. 매 렌더 새 함수가 와도 타이머를 다시 만들지 않는다 — 24편에서 다룬 것과 같은 함정이다.
같이 넣은 게 하나 더 있다. 유저 목록이 include: { account: true, championPool: true }로 전량을 읽고는, account는 !!u.account 불리언 하나로, championPool은 챔피언 이름 세 개로 줄여 쓰고 있었다. 상위 3개만 SQL에서 잘라오게 바꾸니 1.34MB → 5.5KB, 실측 246배였다.
인증 쿼리는 43만 건/일 → 2만 건/일이 됐다. 95% 감소다.
3. 그리고 5분간 유저 목록이 안 열렸다
배포하자 /api/users가 전부 500이 됐다. 즉시 롤백했다(약 5분간 목록 조회 불가).
Raw query failed. Code: `42P18`
Message: could not determine data type of parameter $1
$queryRaw는 태그드 템플릿이라 SQL 문자열 안의 ${...}가 파라미터로 치환된다. jsonpath 리터럴 안에 보간했더니 이렇게 됐다.
'$.mastery[0 to ${n}].championName' → '$.mastery[0 to $1].championName'
Postgres는 문자열 리터럴 내부의 파라미터 타입을 정할 수 없다. 경로를 값으로 넘기고 ::jsonpath로 캐스팅해서 고쳤다.
// packages/api/src/lib/top-champions.ts
// ⚠️ jsonpath 를 **문자열 리터럴 안에 보간하지 말 것.** $queryRaw 는 태그드 템플릿이라
// '$.mastery[0 to ${n}]...' 처럼 쓰면 그 자리가 $1 파라미터로 치환되는데, 리터럴 안의
// 파라미터는 Postgres 가 타입을 못 정해 42P18(could not determine data type)로 죽는다.
// 실제로 그렇게 배포해 유저 목록이 500 이 됐다. 값으로 넘기고 ::jsonpath 로 캐스팅한다.
const path = `$.mastery[0 to ${Math.max(0, limit - 1)}].championName`;
const rows = await prisma.$queryRaw<{ userId: number; names: unknown }[]>`
SELECT "userId",
jsonb_path_query_array(
CASE WHEN data ~ '^[[:space:]]*[{[]' THEN data::jsonb ELSE '{}'::jsonb END,
${path}::jsonpath
) AS names
FROM "ChampionPool"
WHERE "userId" = ANY(${userIds}::int[]) AND "deletedAt" IS NULL
`;
원인은 SQL이 아니라 검증 방식이었다. psql로 SQL만 확인하고 Prisma를 거친 실행을 확인하지 않았다. 같은 SQL이라도 드라이버가 파라미터를 어떻게 만드는지는 다르다. 다시 고친 뒤에는 실제 DB에 대고 함수를 직접 호출해서 결과를 눈으로 확인했다.
테스트도 “SQL이 맞는가”가 아니라 **“경로가 값으로 나가는가”**를 보도록 짰다. SQL 문자열에 '$.mastery'가 남아 있으면 실패한다.
4. 95%를 줄였는데 egress가 그대로다
여기가 이 사건의 분기점이다. 요청을 95% 줄이고 며칠을 지켜봤는데 egress 그래프가 거의 움직이지 않았다.
이유는 단순했다. 인증 쿼리는 1행짜리라 바이트가 거의 없다. 전체 전송량의 약 1%였다. 나는 호출 “횟수”를 줄였지 “바이트”를 줄인 게 아니었다.
egress는 요청 수가 아니라 전송 바이트로 과금된다. 당연한 말인데, pg_stat_statements의 호출 수 순위표를 먼저 본 탓에 그 순위표가 곧 원인 순위라고 착각했다.
그래서 이번엔 EXPLAIN SERIALIZE로 전송 바이트를 기준으로 다시 쟀다. 순위가 완전히 뒤집혔다.
| 테이블 | 하루치 | 비중 |
|---|---|---|
| ChampionPool | 1,677 MB | 63.2% |
| RiotMatchParticipant | 385 MB | 14.5% |
| LfgMember | 312 MB | 11.8% |
| 나머지 5개 테이블 | 279 MB | 10.5% |
| 합계 | 2,653 MB | (16일 실측 2,182MB와 부합) |
ChampionPool.data는 라이엇 마스터리 전체가 담긴 JSON 문자열이고 한 행이 평균 5,005B다. 그런데 화면이 실제로 쓰는 건 그 안의 조각 몇 개뿐이다.
이름 3개 60 B ← 유저 목록·구인 목록의 대표 챔피언
radarStats 267 B ← 라인 평균 육각형
recentResults 2,574 B ← 솔랭 연승왕
쓰는 것의 10~80배를 받아서 버리고 있었다.
5. 고친 경로와 실측
필요한 조각만 Postgres에서 꺼내오도록 다섯 경로를 바꿨다. 파싱을 앱이 아니라 DB에서 하는 게 핵심이다 — 앱에서 JSON.parse하려면 결국 5KB를 받아와야 하므로 읽는 양이 줄지 않는다.
| 경로 | 전 | 후 | 배수 |
|---|---|---|---|
| 구인 목록 1회 | 964 kB | 9 kB | 107배 |
| 솔랭 연승왕 | 1,472 kB | 2 kB | 736배 |
| random-mastery | 1,472 kB | 24 kB | 61배 |
| 라인 평균 육각형 | 1,472 kB | 76 kB | 19배 |
| 외전 로스터 | 멤버수 × 5KB | 이름만 | — |
동작이 같은지는 운영 DB 실데이터 전량으로 대조했다. 대표 챔피언 276명 완전 일치, 마스터리 276명 완전 일치, 레이더 262명 키·값 전부 동일(jsonb 키 순서만 다른데 이름으로만 접근한다), 연승 116명 완전 일치. 구인 라우터는 실제로 띄워서 432글·멤버 3,701명 전원 정상, 응답에 championPool 누출 0건까지 확인했다.
6. 이건 타입 검사로 절대 안 잡힌다
재발 방지를 생각하다가 깨달은 게 있다. championPool: true는 언제나 유효한 코드다. 타입 에러가 아니고, 린트 규칙도 없고, 리뷰에서도 눈에 잘 안 띈다. 한 줄 추가하면 하루 1.6GB가 다시 나가기 시작하는데 아무것도 막아주지 않는다.
그래서 소스를 훑는 테스트를 짰다. 네 가지를 막는다.
championPool: truechampionPool.select.datachampionPool.findManyformatPost를map으로 부르면서Promise.all을 빠뜨리는 것
네 검사 모두 고친 코드를 되돌려 실제로 실패하는 것까지 확인했다. 마지막 항목은 실제로 낼 뻔한 버그다 — formatPost를 async로 바꾸자 .map()이 Promise 배열이 되어, 구인 목록 전체가 [{},{}]로 조용히 나갈 뻔했다.
그리고 이 검사기를 넓히다가 한 번 더 걸렸다.
⚠️ 이 검사는 처음에 틀리게 짰다.
include: { match: { select: {...} } }안의 관계용 select를 최상위 select로 오인해서, 고친 코드를 되돌려도 통과했다. 최상위 키만 남기고 보도록 고쳤고, 되돌려서 두 곳이 실제로 잡히는 것을 확인했다. 검사기 자신을 검사하는 테스트도 넣었다.
26편에서 훅 정적 검사기가 제네릭을 놓쳐 “0건”을 반환했던 것과 같은 실수를 2주도 안 돼 반복한 셈이다. 소스를 정규식으로 훑는 테스트는 거짓 음성이 기본값이다. 통과했다는 사실 자체는 아무 정보도 아니고, 버그 버전에서 실패하는 걸 봐야 비로소 의미가 생긴다.
7. include는 컬럼을 줄이지 않는다
ChampionPool 다음 순위도 손봤는데, 여기서 공통점이 하나 보였다. 문제가 된 곳이 전부 include만 쓰고 select가 없었다.
include는 관계를 붙일 뿐 컬럼을 줄이지 않는다. 그래서 31개 컬럼짜리 테이블에서 8개만 쓰면서도 31개를 다 받아온다.
구인 목록 LfgMember 77.8 MB/일 2,493호출 × 204행 × 13컬럼
크론 ChampionPool 47.2 MB/일 8,534호출 × 1행 × 5,530B
내전 TeamMember 34.7 MB/일 77만행 × 6컬럼
이 중 크론 건은 성격이 달랐다. 매치 수집 크론의 Phase 2와 Phase 4가 각각 ChampionPool을 읽어 병합-저장하는데, 사이에 ChampionPool을 건드리는 단계가 없었다. Phase 4의 읽기는 방금 자기가 쓴 값을 다시 받는 것이었다. Phase 2의 객체를 그대로 넘겨 절반으로 줄였다.
건드리지 않은 것도 기록해 뒀다. 유저 목록 응답이 ...u로 User를 통째로 싣고 있어서, 컬럼을 줄이면 프론트에서 조용히 필드가 사라진다(하루 5.6MB). 이건 응답 스키마를 먼저 정리해야 하는 일이라 미뤘다.
8. 마지막 병목 — 98%가 같은 답을 다시 계산하고 있었다
9월 18일에 마지막 덩어리를 찾았다. refresh 크론이 매 사이클 모든 대상에게 6개월 집계를 돌리고 있었다. 그 함수는 최근 100판을 읽어 온다.
2026-09-18 실측 집계 호출 8,228회/일 · egress 142MB/일
같은 기간 새 매치가 생긴 유저는 시간당 5.4명(최대 13명)
승인 멤버는 133명
시간당 343번 돌려서 5명분만 값이 바뀌었다. 98%가 같은 입력으로 같은 답을 다시 계산하고 있었고, 이게 그 시점 전체 egress 191MB 중 142MB였다.
“새것만 더하기”로는 풀 수 없었다. 집계 창이 누적이 아니라 최근 100판이라, 새 판이 들어오면 오래된 판이 밀려난다. 더하려면 뺄 것도 알아야 하고, 그러려면 창 내용을 들고 있어야 한다. 그래서 증분이 아니라 안 바뀌었으면 건너뛴다로 풀었다.
최초 집계(aggregatedAt 없음) → 돌린다
마지막 매치 시각이 달라짐 → 돌린다
시각이 뒤로 감(삭제·정정) → 돌린다
위 어느 것도 아니고 6시간 이내 → 건너뛴다
6시간 강제 갱신을 남긴 이유가 있다. 새 판이 없어도 오래된 판이 6개월 창 밖으로 빠지면서 결과가 변한다. 이게 없으면 활동을 멈춘 유저의 통계가 영원히 고정된다. 24시간으로 하면 하루 7MB를 더 아끼지만(한도의 4%p) 통계가 4배 늦어져서 6시간으로 정했다.
9. 지금 상태 / 하지 않은 것
측정 기준이 중간에 한 번 바뀌었다는 건 밝혀 둔다. 처음엔 155일 누적 pg_stat_statements를 일평균으로 환산했는데, 5개월치라 지금 코드가 아니었다. 그래서 이후에는 15분 델타로 현재 비율을 따로 쟀다. 위의 2,653MB(환산)와 191MB(델타 실측)는 같은 자로 잰 숫자가 아니다.
ChampionPool전문 읽기 제거로 2,653 → 약 976 MB/일까지 줄였다. 그 시점에도 무료 한도(5GB/월 = 167MB/일)에는 한참 못 미쳤다.include정리와 집계 게이트까지 넣고 9월 18일 기준 전체 191MB/일에서 142MB/일을 걷어냈다.- 그 이후의 안정된 실측치는 이 글을 쓰는 시점에 아직 갖고 있지 않다. 다음 청구 기간을 한 바퀴 돌려봐야 안다.
하지 않기로 한 것도 남겨 둔다.
Cloud Run 리전 이전은 안 했다. Supabase는 AWS(ap-northeast-1), Cloud Run은 GCP다. GCP 도쿄로 옮겨도 클라우드가 달라 인터넷 경유는 그대로고, egress 과금은 **목적지와 무관하게 “프로젝트에서 나간 바이트”**라 1바이트도 줄지 않는다. 줄어드는 건 지연(RTT)뿐이다.
공유 캐시(Redis)도 아직 안 붙였다. 기존 Redis는 WS VM의 로컬호스트라 Cloud Run에서 닿지 않고, Upstash 무료는 월 50만 명령인데 당시 인증 경로만 하루 43만 건이라 반나절이면 소진된다. 지금 붙이면 폴링이 만든 부하를 그대로 유료로 떠안는 셈이라, 폴링을 줄인 뒤 다시 재고 판단하기로 했다.
10. 배운 것
호출 횟수 순위표는 바이트 순위표가 아니다. 이 사건에서 가장 비싼 교훈이다. pg_stat_statements의 calls 컬럼을 보고 범인을 특정했는데, 1위 쿼리는 전체 바이트의 1%였다. 과금 기준이 바이트라면 처음부터 바이트로 재야 했다.
include와 select의 차이는 취향이 아니다. 관계를 붙이는 것과 컬럼을 고르는 것은 다른 일이고, 넓은 테이블에서는 그 차이가 그대로 요금이 된다.
타입 시스템이 막아주지 않는 비용이 있다. championPool: true는 영원히 유효한 코드다. 그래서 소스를 훑는 테스트를 짰고, 그 테스트를 믿기 전에 버그 버전에서 실패하는지 먼저 확인해야 한다는 것도 같이 배웠다 — 두 번이나.
댓글
아직 댓글이 없어요. 첫 댓글을 남겨보세요.